import {
ExternalLinkIcon,
GridIcon,
ListBulletIcon,
PlusIcon,
} from "@radix-ui/react-icons";
import { Button } from "@ui/Button";
import { TextInput } from "@ui/TextInput";
import { useGlobalLocalStorage } from "@common/lib/useGlobalLocalStorage";
import { ProjectCard } from "components/projects/ProjectCard";
import { usePaginatedProjects } from "api/projects";
import { useCurrentTeam } from "api/teams";
import { useTeamOrbSubscription } from "api/billing";
import { useReferralState } from "api/referrals";
import { ProjectDetails, TeamResponse } from "generatedApi";
import { ReferralsBanner } from "components/referral/ReferralsBanner";
import { DocsGrid } from "components/projects/DocsGrid";
import { useCreateProjectModal } from "hooks/useCreateProjectModal";
import { withAuthenticatedPage } from "lib/withAuthenticatedPage";
import Head from "next/head";
import { useState, useEffect } from "react";
import { useDebounce } from "react-use";
import { cn } from "@ui/cn";
import { EmptySection } from "@common/elements/EmptySection";
import { OpenInVercel } from "components/OpenInVercel";
import { LoadingLogo } from "@ui/Loading";
import { PaginationControls } from "elements/PaginationControls";
export { getServerSideProps } from "lib/ssr";
export default withAuthenticatedPage(() => {
const team = useCurrentTeam();
const referralState = useReferralState(team?.id);
const [showAsList] = useGlobalLocalStorage("showProjectsAsList", false);
const { subscription } = useTeamOrbSubscription(team?.id);
const isFreePlan =
subscription === undefined ? undefined : subscription === null;
const [prefersReferralsBannerHidden, setPrefersReferralsBannerHidden] =
useGlobalLocalStorage("prefersReferralsBannerHidden", false);
return (
<>
<Head>{team && <title>{team.name} | Convex Dashboard</title>}</Head>
<div className="h-full grow bg-background-primary p-4">
<div
className={cn(
"m-auto transition-all",
showAsList ? "max-w-3xl" : "max-w-3xl lg:max-w-5xl xl:max-w-7xl",
)}
>
<div className="flex w-full flex-col gap-2">
{team && (
<div className="w-full">
<ProjectGrid
team={team}
referralState={referralState}
isFreePlan={isFreePlan}
prefersReferralsBannerHidden={prefersReferralsBannerHidden}
setPrefersReferralsBannerHidden={
setPrefersReferralsBannerHidden
}
/>
</div>
)}
</div>
<DocsGrid />
</div>
</div>
</>
);
});
function ProjectGrid({
team,
referralState,
isFreePlan,
prefersReferralsBannerHidden,
setPrefersReferralsBannerHidden,
}: {
team: TeamResponse;
referralState: any;
isFreePlan: boolean | undefined;
prefersReferralsBannerHidden: boolean;
setPrefersReferralsBannerHidden: (value: boolean) => void;
}) {
const [createProjectModal, showCreateProjectModal] = useCreateProjectModal();
const [showAsList, setShowAsList] = useGlobalLocalStorage(
"showProjectsAsList",
false,
);
const [pageSize, setPageSize] = useGlobalLocalStorage("projectsPageSize", 25);
const [projectQuery, setProjectQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [currentCursor, setCurrentCursor] = useState<string | undefined>(
undefined,
);
const [cursorHistory, setCursorHistory] = useState<(string | undefined)[]>([
undefined,
]);
// Debounce search query (300ms delay)
useDebounce(
() => {
setDebouncedQuery(projectQuery);
},
300,
[projectQuery],
);
// Fetch paginated projects with debounced query
const paginatedData = usePaginatedProjects(
team?.id,
{
cursor: currentCursor,
q: debouncedQuery.trim() || undefined,
},
30000,
);
const projects = paginatedData?.items ?? [];
const hasMore = paginatedData?.pagination.hasMore ?? false;
const nextCursor = paginatedData?.pagination.nextCursor;
const isLoading = paginatedData === undefined;
// Calculate current page range for display
const currentPageNumber = cursorHistory.length;
const isReferralsBannerVisible =
projects.length > 0 &&
isFreePlan &&
referralState &&
!prefersReferralsBannerHidden;
const handleNextPage = () => {
if (nextCursor) {
setCursorHistory((prev) => [...prev, currentCursor]);
setCurrentCursor(nextCursor);
}
};
const handlePrevPage = () => {
if (cursorHistory.length > 1) {
const newHistory = [...cursorHistory];
newHistory.pop();
setCursorHistory(newHistory);
setCurrentCursor(newHistory[newHistory.length - 1]);
}
};
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
// Reset to first page when page size changes
setCurrentCursor(undefined);
setCursorHistory([undefined]);
};
// Reset cursor when debounced search query changes
useEffect(() => {
setCurrentCursor(undefined);
setCursorHistory([undefined]);
}, [debouncedQuery]);
return (
<div className="flex flex-col items-center">
{isReferralsBannerVisible && (
<ReferralsBanner
team={team}
referralState={referralState}
onHide={() => setPrefersReferralsBannerHidden(true)}
/>
)}
<div className="mb-4 flex w-full animate-fadeInFromLoading flex-col flex-wrap gap-4 sm:flex-row sm:items-center">
<h3>Projects</h3>
<div className="flex flex-wrap gap-2 sm:ml-auto sm:flex-nowrap">
<div className="hidden gap-1 rounded-md border bg-background-secondary p-1 lg:flex">
<Button
icon={<GridIcon />}
variant="neutral"
inline
size="xs"
className={cn(!showAsList && "bg-background-tertiary")}
onClick={() => setShowAsList(false)}
/>
<Button
icon={<ListBulletIcon />}
variant="neutral"
inline
size="xs"
className={cn(showAsList && "bg-background-tertiary")}
onClick={() => setShowAsList(true)}
/>
</div>
<TextInput
outerClassname="min-w-[13rem] max-w-xs"
placeholder="Search projects"
value={projectQuery}
onChange={(e) => setProjectQuery(e.target.value)}
type="search"
id="Search projects"
isSearchLoading={isLoading && debouncedQuery === projectQuery}
/>
{!team.managedBy && (
<Button
onClick={() => showCreateProjectModal()}
variant="neutral"
size="sm"
icon={<PlusIcon />}
>
Create Project
</Button>
)}
<OpenInVercel team={team} />
{projects.length > 0 && (
<Button
href="https://docs.convex.dev/tutorial"
size="sm"
target="_blank"
icon={<ExternalLinkIcon />}
>
Start Tutorial
</Button>
)}
</div>
</div>
{projects.length === 0 && isLoading && (
<div className="my-24 flex flex-col items-center gap-2">
<LoadingLogo />
</div>
)}
{projects.length === 0 && !isLoading && debouncedQuery.trim() && (
<div className="my-24 flex animate-fadeInFromLoading flex-col items-center gap-2 text-content-secondary">
There are no projects matching your search.
</div>
)}
{projects.length === 0 && !isLoading && !debouncedQuery.trim() && (
<EmptySection
header="Welcome to Convex!"
sheet={false}
body={
<>
<p className="text-sm">
This team doesn't have any projects yet.{" "}
</p>
<p className="text-sm">Get started by following the tutorial.</p>
</>
}
action={
<Button
href="https://docs.convex.dev/tutorial"
target="_blank"
icon={<ExternalLinkIcon />}
className="mt-2"
>
Start Tutorial
</Button>
}
/>
)}
<div
className={cn(
"mb-4 grid w-full grow grid-cols-1 gap-4",
!showAsList && "lg:grid-cols-2 xl:grid-cols-3",
)}
>
{/* In case the page returned more items than requested, slice the result down to the page size. This only happens for the first page of SSRed data. */}
{projects.slice(0, pageSize).map((p: ProjectDetails) => (
<ProjectCard key={p.id} project={p} />
))}
</div>
{/* Bottom pagination controls */}
{projects.length > 0 && (
<div className="mb-4 flex w-full justify-end">
<PaginationControls
showPageSize
isCursorBasedPagination
currentPage={currentPageNumber}
hasMore={hasMore}
pageSize={pageSize}
onPageSizeChange={handlePageSizeChange}
onPreviousPage={handlePrevPage}
onNextPage={handleNextPage}
canGoPrevious={cursorHistory.length > 1}
/>
</div>
)}
{createProjectModal}
</div>
);
}